feat(mcp): detect concealed Unicode in tool listings - #154
Merged
Conversation
Closes #152. The fingerprint answers "did this server change what it advertises". It says nothing about a server that was hostile from the first listing anybody took, and that trust-on-first-use gap has now been raised twice in review. Concealed control characters are the exception, and the only reason this check earns its own signal. They need no baseline and no history, because there is no legitimate reason for a tool description to carry a Unicode TAG block. U+E0000 to U+E007F mirrors ASCII, so an entire second instruction can be written in it and rendered as an empty string. Research calls the result an approval-view fidelity gap, and the phrase is exact: the human approving the tool reads one string and the model receives another. Recording what the model was handed rather than what a human was shown is this project's whole premise, and here the two demonstrably differ. Counts per category, never the text, never which tool. A finding that carries a concealed instruction has copied that instruction into the trail and then forwarded it to a SIEM. Walks every string in a tool definition rather than a list of field names. #103 settled that argument for the severity split and it applies for the same reason: an allowlist fails open on whatever the spec adds next. A property description nested in inputSchema is exactly what a named list would miss, and so is _meta, which was exempt from hashing until #142. Private Use Area is deliberately not a category. Icon fonts use it legitimately and a category that cries wolf costs more than the one case it might catch. Lives in core/diagnostics, which is outside _RULESET_SOURCES, so the dogfood clock is untouched. Declared on ExternalIngestBody deliberately: an undeclared field is dropped silently and that has already cost this file two shipped bugs. test_ingest_roundtrip.py would have caught it either way, which is the point of having written it. 1182 tests, benchmark 26 of 26 with 0 missed and 0 false positives, ruleset fingerprint unchanged at 15846a0915769d4a. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #152.
Why this one is different from the rest of the MCP backlog
The fingerprint answers "did this server change what it advertises". It says nothing about a server that was hostile from the first listing anybody ever took. That trust-on-first-use gap has been raised twice in review and is real.
Concealed control characters are the exception, and the only reason this earns a separate signal: they need no baseline and no history. There is no legitimate reason for a tool description to carry a Unicode TAG block.
U+E0000 to U+E007F mirrors ASCII, so a whole second instruction can be written in it and render as an empty string:
Research calls this an approval-view fidelity gap, and the phrase is exact: the human approving the tool reads one string, the model receives another. Recording what the model was handed rather than what a human was shown is this project's entire premise, and here the two demonstrably differ.
What it emits
Counts per category. Never the text, never which tool. A finding that carries a concealed instruction has copied that instruction into the trail and forwarded it to a SIEM.
Categories:
tag_block,zero_width(ZWSP, ZWNJ, ZWJ, BOM, soft hyphen),bidi_control.Design decisions worth reviewing
Walks every string, not a field list. #103 settled that argument for the severity split and it applies identically here: an allowlist fails open on whatever the spec adds next. Tested against a property description nested in
inputSchemaand against_meta, both of which a named list would have missed.Private Use Area is deliberately not a category. Icon fonts use it legitimately, and a category that cries wolf costs more than the one case it might catch. There's a test asserting it stays unflagged.
Attached on
newas well aschanged. Everything else in this module needs a prior. This does not, and a test pins that it fires on a server never seen before.Omitted entirely when clean, so
mcp_schema, the quietest event class in the trail, gains no field on a well-behaved server.Not blocked by the freeze
core/diagnostics/mcp_schema.pyis outside_RULESET_SOURCES. Ruleset fingerprint unchanged at15846a0915769d4a, dogfood clock untouched.The field is declared on
ExternalIngestBody, because an undeclared field is dropped silently and that has already cost this file two shipped bugs.test_ingest_roundtrip.pywould have caught it regardless, which is the point of having written it.1,182 tests, ruff clean, benchmark 26/26 with 0 missed and 0 false positives.
🤖 Generated with Claude Code